[ 롤모임 운영일지 ] - 28. 문서를 코드 기준으로 다시 쓰다 버그를 줄줄이 찾았다
이 프로젝트에는 기능별 정책 문서가 있다. 구인, 경매, 배팅, 상점, 멘토링 같은 것들을 기능마다 한 편씩, “이 기능은 이런 규칙으로 돈다”를 시나리오와 상수표까지 포함해 적어 둔 것이다. 테스트 스펙 역할도 겸한다.
9월 20일에 그 문서들을 열어봤더니 대부분 5월 27일 기준이었다. 넉 달 전이다. 그 사이 커밋이 수백 개 쌓였다.
문서를 코드 기준으로 다시 쓰기로 하고 하루를 잡았는데, 결과적으로 그날 나온 건 문서 커밋 네 개와 버그 수정 커밋 세 개였다. 문서와 코드를 한 줄씩 대조하는 작업이 그 자체로 꽤 좋은 버그 탐지기였다.
TL;DR
- 문서가 설명하던 배팅의 핵심 규칙(“5분 룰”)이 코드에 없었다. 6월 9일에 없어졌는데 문서는 시나리오 4개 + 유즈케이스 2개 + 상수표에서 계속 그 규칙을 설명하고 있었다.
- 경매 정원이
ceil(참가자수 / 팀수)인데 5로 하드코딩된 곳이 2곳 남아 있었다. 30명 4팀(정원 8)처럼 큰 판에서만 어긋나서 눈에 안 띄던 종류다. - 선배팅 우선권 창이 REST 5000ms · WS 3000ms · 화면 5초 — 셋이 각자 숫자를 들고 있었다. WS로 붙은 사람은 “3초 남음”으로 보이는 구간에 우선권이 이미 풀려 있었다.
- 멘토링 목표의 티어 별칭이 검증은 통과하는데 변환에서 0이 됐다. 멘토가 수락하는 순간 달성 불가능한 목표가 만들어진다.
- 문서를 쓰다가 코드는 건드리지 않고 기록만 해 둔 운영 이슈도 몇 개 나왔다(5절).
1. “5분 룰”은 넉 달 전에 없어졌다
연승 배팅 문서를 열었더니 핵심 규칙이 이렇게 적혀 있었다. 게임이 시작된 지 5분이 지나면 그 판에는 배팅할 수 없고 다음 판으로 넘어간다 — 판세가 보이는 상태에서 거는 걸 막기 위한 규칙이다.
코드에는 그 규칙이 없었다. 해당 상수가 6월 9일에 5분 → 0이 됐다. 지금은 시작된 게임이면 경과 시간과 무관하게 전부 다음 판으로 넘어간다. 같은 의도를 더 강하게 적용한 변경이었는데, 문서는 갱신되지 않았다.
문서 한 곳만 틀린 게 아니었다. 시나리오 4개, 유즈케이스 2개, 상수표가 전부 5분을 전제로 쓰여 있었다. 문서를 읽고 기능을 이해한 사람은 존재하지 않는 규칙을 사실로 알게 되는 상태였다.
정산 크론도 틀렸다. 문서는 6시간 주기의 어떤 엔드포인트라고 했지만, 실제로는 다른 크론 안에서 돌고 있었고 스케줄러 주기는 30분이었다. 소스 코드의 주석 두 개도 실제 스케줄과 달라서 같이 고쳤다. 권한도 마찬가지 — 문서는 관리자 전용이라고 했는데 실제로는 배팅 매니저 권한이었다.
선언만 되고 아무 데서도 쓰이지 않는 죽은 상수도 하나 찾았다.
2. 정원 5 하드코딩 — 큰 판에서만 어긋난다
경매 문서를 대조하다 발견한 건 실제 버그였다.
팀 정원은 ceil(참가자수 / 팀수)로 계산한다. 10명 2팀이면 5, 30명 4팀이면 8이다. 그런데 두 곳이 5로 하드코딩돼 있었다.
- 실시간 경매 화면의 입찰 게이트 → 6번째 픽부터 “팀이 가득 찼습니다”로 막혔다
- WS 서버의 선배팅 우선권 후보 필터 → REST와 다른 팀을 예산 1위로 볼 수 있었다
10명 2팀 경매에서는 정원이 정확히 5라서 아무 문제가 없다. 그래서 여태 안 보였다. 30명 4팀 같은 큰 판을 열 때만 어긋난다.
한쪽은 더 민망했다. 바로 윗줄에서 이미 같은 식으로 정원을 계산하고 있었는데, 몇 줄 아래에서 5를 다시 쓰고 있었다.
3. 같은 값을 세 곳이 따로 들고 있었다
이어서 나온 게 선배팅 우선권 창이다. 라운드가 열린 뒤 일정 시간은 예산 1위 팀만 먼저 입찰할 수 있는 규칙인데, 숫자가 세 개였다.
REST API 5000 ms
WS 서버 3000 ms
화면 5초 (양쪽 모두)
WS로 접속한 사람은 화면에 “3초 남음”으로 표시되는 구간에 우선권이 이미 풀려 있었다. 예산 1위 팀이 먼저 질러보지도 못했는데 밀리는 일이 생긴다. 실시간 경매는 03편에서 다뤘듯 API와 WS 두 서버가 상태를 나눠 갖는 구조라, 이런 숫자가 갈라지면 화면마다 다른 진실이 보인다.
처방은 앞서 정원 계산을 정리할 때 쓴 것과 같다. 정본 상수와 그걸 쓰는 함수를 한 곳에 두고, 세 경로가 같이 쓰게 한다.
// packages/api/src/lib/auction-bid.ts
/**
* 선배팅 우선권 창 — 라운드가 열린 뒤 이만큼은 "예산 1위 팀"만 먼저 입찰할 수 있다.
*
* ⚠️ REST·WS·프론트가 각자 숫자를 들고 있으면 안 된다. 2026-09-20 까지 REST 5000 / WS 3000 이었고
* 화면은 양쪽 다 5초로 카운트다운했다 — WS 로 붙은 사람은 "3초 남음"으로 보이는 구간에
* 우선권이 이미 풀려 있어서, 1위 팀이 먼저 못 질렀는데도 밀렸다.
*/
export const PRIORITY_BID_WINDOW_MS = 5000;
/** 우선권 창이 아직 열려 있는가. 남은 초는 priorityBidSecondsLeft 로. */
export function inPriorityBidWindow(elapsedMs: number): boolean {
return elapsedMs < PRIORITY_BID_WINDOW_MS;
}
/** 화면·메시지에 쓸 "N초 남음". 창이 닫혔으면 0. */
export function priorityBidSecondsLeft(elapsedMs: number): number {
return Math.max(0, Math.ceil((PRIORITY_BID_WINDOW_MS - elapsedMs) / 1000));
}
상수만 내보내면 각자 Math.floor와 Math.ceil을 골라 쓰다가 또 갈라진다. 그래서 판정과 표시를 함수로 같이 내보냈다. 남은 초는 올림이고 창이 닫히면 0이라 음수가 나오지 않는다.
4. 검증은 통과하는데 변환에서 0이 되던 것
멘토링 문서를 대조하다 나온 건 아직 아무도 겪지 않은 버그였다.
멘토링은 멘티의 목표 티어를 입력받아 절대 LP로 변환해 저장한다. 그런데 티어 입력을 검사하는 함수는 별칭(“에메”, “플레” 같은 줄임말)을 풀어주고, 변환하는 함수는 풀지 않았다.
// packages/api/src/lib/mentoring-goal.ts
// ⚠️ isValidGoalTier 와 **같은 방식**으로 별칭을 풀어야 한다.
// 풀지 않으면 "에메 1"·"플레 2" 가 검증은 통과하는데 여기서 0 이 되어,
// 멘토가 수락하는 순간 goalAbsLP=0 인 퇴화 목표가 만들어진다(보상도 0).
// 그러면 멘티는 영원히 달성 처리를 못 하고 "목표 재설정을 요청하세요"만 본다.
const base = TIER_ALIASES[parts[0]] ?? parts[0];
“에메 1”을 입력하면 검증은 통과한다. 변환에서 0이 된다. 멘토가 수락하는 순간 목표 LP가 0인 목표가 만들어지고, 보상도 0이다. 멘티 입장에서는 멀쩡한 티어를 입력했는데 영원히 달성 처리가 안 되고 “멘토에게 목표 재설정을 요청하세요”만 보게 된다.
두 함수가 어긋나면 깨지는 회귀 테스트를 붙였고, 수정 전 코드로 되돌려 실제로 실패하는 것까지 확인했다. 운영 DB에 이 형태로 등록된 목표는 현재 0건이다 — 터지기 전에 잡았다.
문서 쪽에서도 세 가지가 누락돼 있었다. 마스터 이상 목표의 LP 처리(예전엔 LP를 버려서 “마스터 0LP”로 축소돼 오달성됐다), 목표가 시작점보다 낮거나 같으면 막는 가드, 그리고 검증 함수의 존재 자체가 문서에 없었다.
5. 코드는 안 건드리고 기록만 해 둔 것들
문서를 쓰려면 “지금 실제로 어떻게 돌고 있나”를 봐야 해서, 운영 DB를 같이 조회했다. 그러다 코드 버그는 아닌데 운영 판단이 필요한 것들이 나왔다. 이런 건 고치지 않고 문서에 적어만 뒀다. 혼자 정할 일이 아니어서다.
잭팟 풀이 유통량의 23.3%를 쥐고 있다. 복권에서 꽝이 나올 때마다 일정 포인트가 잭팟 풀에 쌓이는데, 당첨 확률이 1e-7이다. 현재 추첨량 기준으로 연간 당첨 확률이 0.25%, 기대 대기 시간이 약 400년이다. 추첨 1회당 평균 25.4P가 사실상 영구히 빠져나간다. 21편에서 꽝 확률을 82%까지 올린 건 인플레이션을 잡으려는 의도였고, 이것도 같은 방향의 싱크(sink)다. 다만 “의도한 싱크인가, 아니면 영영 안 터지는 당근인가”는 코드가 답할 수 없다.
유찰 자동배정의 음수 예산이 상점 포인트를 부풀린다. 경매가 끝나고 남은 예산을 포인트로 환산할 때 budget - min(전체 팀장) 공식을 쓰는데, min이 음수면 다른 팀장 전원이 그만큼 더 받는다. 최근 한 달 10건, 그중 한 세션은 팀장 3명이 각각 +20P를 더 받았다.
배팅 대상 목록이 멤버십이 아니라 User.groupId로 대상을 고른다. 25편에서 다룬 것과 같은 자리인데, 여기는 방향이 반대다. 승인 멤버십이 97건인데 109명을 집고, 그중 솔랭 기록이 있어 실제로 목록에 뜰 수 있는 미승인 유저가 12명이다. 반대로 원적 유저가 0명인 신생 모임은 목록이 통째로 빈다.
관리 화면의 보유 현황 조회에 500건 상한이 있다. 클라이언트에서 세면 집계가 조용히 틀어진다. 아이템 하나가 이미 223건이라 상한에 닿는 건 시간 문제다. 그래서 서버에서 집계하는 엔드포인트를 따로 두고 문서에 적었다.
6. 상수표의 줄번호는 썩는다
문서에는 “이 상수는 저 파일 몇 번째 줄” 식의 상수표가 있었다. 넉 달 지나니 줄번호가 전부 틀려 있었다. 파일은 계속 수정되니까 당연한 결과다.
전부 파일 경로 + 심볼 이름으로 바꿨다. 줄번호는 코드가 한 줄만 추가돼도 틀리지만, 심볼 이름은 이름을 바꾸기 전까지 유지된다. 그리고 이름을 바꾸는 건 줄이 밀리는 것과 달리 의식적인 행동이라, 문서도 같이 고칠 확률이 높다.
7. 지금 상태 / 배운 것
- 정책 문서 네 편(구인·경매·상점·연승 배팅)과 멘토링 문서를 코드·운영 실측 기준으로 다시 썼다.
- 같은 날 버그 수정 세 건이 같이 나왔다: 정원 5 하드코딩 2곳, 우선권 창 3개 값 통일, 티어 별칭 변환.
- 운영 판단이 필요한 건 고치지 않고 문서에 실측과 함께 남겼다.
배운 건 문서를 코드 기준으로 다시 쓰는 작업이 꽤 효율적인 버그 탐지 방법이라는 것이다. 코드 리뷰는 “이 코드가 맞는가”를 보지만, 문서 대조는 “이 코드가 우리가 안다고 믿는 것과 같은가”를 본다. 후자가 잡아내는 종류가 따로 있다. 하드코딩된 5, 세 곳에 흩어진 같은 값, 검증과 변환의 불일치는 전부 각 파일만 보면 멀쩡해 보인다. 두 곳을 나란히 놓아야 어긋난 게 보인다.
그리고 **문서가 틀리는 방식은 “낡는 것”이 아니라 “거짓말이 되는 것”**이다. 5분 룰은 그냥 오래된 정보가 아니라, 읽은 사람이 존재하지 않는 규칙을 사실로 믿게 만드는 문장이었다. 그럴 바에는 없는 게 나은 순간이 온다 — 그래서 이번엔 “확인 안 한 것”과 “확인한 것”을 문서 안에서 구분해 적었다.
댓글
아직 댓글이 없어요. 첫 댓글을 남겨보세요.